A Sort Of A Blog
旧游无处不堪寻,无寻处,惟有少年心
C Primer Plus(七)

本篇,我们主要讨论字符串的性质、如何声明并初始化字符串、如何在程序中输入和输出字符串,以及如何操控字符串。

字符串是 C 语言中最有用、最重要的数据类型之一。前面介绍过,字符串是以空字符(\0)结尾的 char 类型数组。我们可以把之前几篇介绍的数组和指针的知识应用于字符串。并且,由于字符串十分常用,所以 C 提供了许多专门用于处理字符串的函数,我们之后会一一介绍。

定义字符串


我们可以使用多种方式来定义字符串:

  • 字符串常量
  • char 类型数组
  • 指向 char 的指针

需要注意: 程序应该确保有足够的空间储存字符串,这一点我们稍后讨论。

字符串字面量(字符串常量)

用双引号括起来的内容称为字符串字面量(string literal),也叫作字符串常量(string constant)。双引号中的字符和编译器自动加入末尾的 \0 字符,都作为字符串储存在内存中。

从 ANSI C 标准起,如果字符串字面量之间没有间隔,或者用空白字符分隔,C 会将其视为串联起来的字符串字面量。例如:

char greeting[50] = "Hello, and"" how are" " you"
" today!";

// 等价于
char greeting[50] = "Hello, and how are you today!";

如果要在字符串内部使用双引号,必须在双引号前面加上一个反斜杠 \ 进行转义:

printf("\"Run, Spot, run!\" exclaimed Dick.\n");
// "Run, Spot, run!" exclaimed Dick.

字符串常量属于静态存储类别(static storage class),这说明如果在函数中使用字符串常量,该字符串只会被储存一次,在整个程序的生命期内存在,即使函数被调用多次。用双引号括起来的内容被视为指向该字符串储存位置的指针。这类似于把数组名作为指向该数组位置的指针。注意如下示例的输出:

#include <stdio.h>

int main(void) {
printf("%s, %p, %c", "We", "are", "champion");
// We, 0x100000f61, c
return 0;
}

字符串数组和初始化

定义字符串数组时,必须让编译器知道需要多少空间。一种方法是用足够空间的数组储存字符串。如下:

const char m1[40] = "Limit yourself to one line's worth.";

这种形式的初始化比标准的数组初始化形式简单得多:

const char m1[40] = {
'L', 'i', 'm', 'i', 't', ' ', 'y', 'o', 'u', 'r', 's', 'e', 'l',
'f', ' ', 't', 'o', ' ', 'o', 'n', 'e', ' ','l', 'i', 'n', 'e',
'\", 's', ' ', 'w', 'o', 'r','t', 'h', '.', '\0'
};

需要特别注意最后的空字符。没有这个空字符,这就不是一个字符串,而是一个普通的字符数组。为了容纳空字符,在指定数组大小时,要确保数组的元素个数至少比字符串长度多 1。所有未被使用的元素都被自动初始化为 0(即空字符,不是数字字符 0)。

通常,让编译器确定数组的大小很方便。前面介绍过,省略数组初始化声明中的大小,编译器会自动计算数组的大小:

const char m2[] = "If you can't think of anything, fake it.";

让编译器确定初始化字符数组的大小很合理。因为处理字符串的函数通常都不知道数组的大小,这些函数通过查找字符串末尾的空字符确定字符串在何处结束。
让编译器计算数组的大小只能用在初始化数组时。如果创建一个稍后再填充的数组,就必须在声明时指定大小。

字符数组名和其他数组名一样,是该数组首元素的地址。因此:

char car[10] = "Tata";

// car == &car[0]、*car == 'T'、*(car+1) == car[1] == 'a'

指针声明

还可以使用指针表示法创建字符串。例如:

const char * pt1 = "Something is pointing at me.";

该声明和下面的声明几乎相同:

const char ar1[] = "Something is pointing at me.";

数组形式和指针形式有何不同?以上面的声明为例,数组形式(ar1[])在计算机的内存中分配为一个内含 29 个元素的数组(每个元素对应一个字符,还加上一个末尾的空字符’\0’)。每个元素被初始化为字符串字面量对应的字符。通常,字符串都作为可执行文件的一部分储存在数据段中。当把程序载入内存时,也载入了程序中的字符串。字符串储存在静态存储区(static memory)中。但是,程序在开始运行时才会为该数组分配内存。此时,才将字符串拷贝到数组中。注意,此时字符串有两个副本。一个是在静态内存中的字符串字面量,另一个是储存在 ar1 数组中的字符串。

此后,编译器便把数组名 ar1 识别为该数组首元素地址(&ar1[0])的别名。在数组形式中,ar1 是地址常量。可以进行类似 ar1+1 这样的操作,标识数组的下一个元素。但是不允许进行 ++ar1 这样的操作。

指针形式(*pt1)也使得编译器为字符串在静态存储区预留 29 个元素的空间。另外,一旦开始执行程序,它会为指针变量 pt1 留出一个储存位置,并把字符串的地址储存在指针变量中。该变量最初指向该字符串的首字符,但是它的值可以改变。因此,可以使用递增运算符。例如,++pt1 将指向第 2 个字符(o)。

字符串字面量被视为 const 数据。由于 pt1 指向这个 const 数据,所以应该把 pt1 声明为指向 const 数据的指针。这意味着不能用 pt1 改变它所指向的数据,但是仍然可以改变 pt1 的值(即,pt1 指向的位置)。如果把一个字符串字面量拷贝给一个数组,就可以随意改变数据,除非把数组声明为 const。

字符串输入


如果想把一个字符串读入程序,首先必须预留储存该字符串的空间,然后用输入函数获取该字符串。

分配空间

字符串输入,第 1 件事是分配空间,以储存稍后读入的字符串。前面提到过,这意味着必须要为字符串分配足够的空间。不要指望计算机在读取字符串时顺便计算它的长度,然后再分配空间。
最简单的方法是,在声明时显式指明数组的大小:

char name[81];

现在 name 是一个已分配块(81 字节)的地址。还有一种方法是使用 C 库函数来分配内存,我们之后会详细介绍。

gets() 函数

在读取字符串时,scanf() 和转换说明 %s 只能读取一个单词。在程序中经常要读取一整行输入,而不仅仅是一个单词。许多年前,gets() 函数就用于处理这种情况。gets() 函数简单易用,它读取整行输入,直至遇到换行符,然后丢弃换行符,储存其余字符,并在这些字符的末尾添加一个空字符使其成为一个 C 字符串。它经常和 puts() 函数配对使用,该函数用于显示字符串,并在末尾添加换行符。

需要注意: gets() 唯一的参数是一个数组,它无法检查该数组是否装得下输入行。因此,gets() 函数只知道数组的开始处,并不知道数组中有多少个元素。如果输入的字符串过长,会导致缓冲区溢出(buffer overflow)。

我们在使用编译器编译带有 gets() 函数的代码时,会产生 “the `gets’ function is dangerous and should not be used.” 警告,
因为该函数的不安全行为造成了安全隐患。过去,有些人通过系统编程,利用 gets() 插入和运行一些破坏系统安全的代码。制定 C99 标准的委员承认了 gets() 的问题并建议不要再使用它。C11 标准委员会采取了更强硬的态度,直接从标准中废除了 gets() 函数。然而在实际应用中,编译器为了能兼容以前的代码,大部分都继续支持 gets() 函数。

fgets() 和 gets_s() 函数

过去通常用 fgets() 来代替 gets(),fgets() 函数稍微复杂些。C11 标准新增的 gets_s() 函数也可代替 gets()。但是,它是 stdio.h 输入/输出函数系列中的可选扩展,所以支持 C11 的编译器也不一定支持它。

fgets() 函数通过第 2 个参数限制读入的字符数来解决溢出的问题。该函数专门设计用于处理文件输入,所以一般情况下可能不太好用。
fgets() 函数的第 2 个参数指明了读入字符的最大数量。如果该参数的值是 n,那么 fgets() 将读入 n-1 个字符,或者读到遇到的第一个换行符为止。

如果 fgets() 读到一个换行符,会把它储存在字符串中。这点与 gets() 不同,gets() 会丢弃换行符。
fgets() 函数的第 3 个参数指明要读入的文件。如果读入从键盘输入的数据,则以 stdin(标准输入)作为参数,该标识符定义在 stdio.h 中。

和 puts() 类似,也存在 fputs() 函数。fputs() 函数的第 2 个参数指明它要写入的文件。如果要显示在计算机显示器上,应使用 stdout(标准输出)作为该参数。puts() 函数会在待输出字符串末尾添加一个换行符,而 fputs() 不会这样做。

C11 新增的 gets_s() 函数(可选)和 fgets() 类似,用一个参数限制读入的字符数。与 fgets() 函数不同的是:

  • gets_s() 只从标准输入中读取数据,所以不需要第 3 个参数
  • 如果 gets_s() 读到换行符,会丢弃它而不是储存它

字符串函数


C 库提供了多个处理字符串的函数,ANSI C 把这些函数的原型放在 string.h 头文件中。其中最常用的函数有 strlen()、strcat()、strcmp()、strncmp()、strcpy()和 strncpy()。

strlen() 函数

strlen() 函数用于统计字符串的长度。如下:

void fit(char *string, unsigned int size) {
if (strlen(string) > size)
string[size] = '\0';
}

strcat() 函数

strcat()(用于拼接字符串)函数接受两个字符串作为参数。该函数把第 2 个字符串的备份附加在第 1 个字符串末尾,并把拼接后形成的新字符串作为第 1 个字符串,第 2 个字符串不变。

#include <stdio.h>
#include <string.h> /* strcat() 函数的原型在该头文件中 */
#define SIZE 80

char * s_gets(char * st, int n);

int main(void) {
char flower[SIZE];
char addon [] = "s smell like old shoes.";
puts("What is your favorite flower?");
if (s_gets(flower, SIZE)) {
strcat(flower, addon);
puts(flower);
puts(addon);
} else
puts("End of file encountered!");
puts("bye");

return 0;
}

char * s_gets(char * st, int n) {
char * ret_val;
int i = 0;
ret_val = fgets(st, n, stdin);
if (ret_val) {
while (st[i] != '\n' && st[i] != '\0')
i++;
if (st[i] == '\n')
st[i] = '\0';
else
while (getchar() != '\n')
continue;
}
return ret_val;
}

// What is your favorite flower?
// wonderflower
// wonderflowers smell like old shoes.
// s smell like old shoes.
// bye

strncat() 函数

strcat() 函数无法检查第 1 个数组是否能容纳第 2 个字符串。如果分配给第 1 个数组的空间不够大,多出来的字符溢出到相邻存储单元时就会出问题。我们可以使用 strncat(),该函数的第 3 个参数指定了最大添加字符数。

strcmp() 函数

strcmp 可以比较两个字符串,注意该函数要比较的是字符串的内容,不是字符串的地址。该函数通过比较运算符来比较字符串,就像比较数字一样。如果两个字符串参数相同,该函数就返回 0,否则返回非零值。如果在字母表中第 1 个字符串位于第 2 个字符串前面,strcmp() 就返回负数,反之 strcmp() 则返回正数。

strncmp() 函数

strcmp() 函数比较字符串中的字符,直到发现不同的字符为止,这一过程可能会持续到字符串的末尾。而 strncmp() 函数在比较两个字符串时,可以比较到字符不同的地方,也可以只比较第 3 个参数指定的字符数。

strcpy() 和 strncpy() 函数

如果 pts1 和 pts2 都是指向字符串的指针,那么下面语句拷贝的是字符串的地址而不是字符串本身:

pts2 = pts1;

如果希望拷贝整个字符串,要使用 strcpy() 函数。

char target[20];
int x;
x = 50; /* 数字赋值*/
strcpy(target, "Hi ho!"); /* 字符串赋值*/
target = "So long"; /* 语法错误 */

strcpy() 接受两个字符串指针作为参数,可以把指向源字符串声明为指针、数组名或字符串常量,而指向源字符串副本的第 1 个指针应指向一个数据对象(如数组),且该对象有足够的空间储存源字符串的副本。
注意:

  • strcpy() 的返回类型是 char *,该函数返回的是第 1 个参数的值
  • 第 1 个参数不必指向数组的开始

strcpy() 和 strcat() 都有同样的问题,它们都不能检查目标空间是否能容纳源字符串的副本。拷贝字符串用 strncpy() 更安全,该函数的第 3 个参数指明可拷贝的最大字符数。